iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

Kotlin Lambda 從零開始系列 第 8

Kotlin Lambda 從零開始 Day 08:find / indexOfFirst / indexOfLast — 位置導向尋找

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 手刻 myFindmyFindLastmyIndexOfFirstmyIndexOfLast,再加上不帶 predicate 的 myIndexOfmyLastIndexOf

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
find { } FirstOrDefault(predicate) find 就是 firstOrNull 的別名
findLast { } LastOrDefault(predicate)
indexOfFirst { } FindIndex() (List) C# 的 FindIndex 在 List<T> 上
indexOfLast { } FindLastIndex() (List)
indexOf(element) IndexOf(element) == 比較
lastIndexOf(element) LastIndexOf(element)

TDD 實作 myFind 與 myFindLast

Red:先寫測試

@Test
fun `find returns first matching element`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertEquals(4, numbers.myFind { it > 3 })
}

@Test
fun `find returns null when no match`() {
    val numbers = listOf(1, 2, 3)
    assertNull(numbers.myFind { it > 10 })
}

@Test
fun `find first Engineering employee`() {
    val result = employees.myFind { it.department == "Engineering" }
    assertEquals("Alice", result?.name)
}

findLast 的測試對稱,資料照用,有命中的那兩個改成期望最後一個符合的元素

@Test
fun `findLast returns last matching element`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertEquals(5, numbers.myFindLast { it > 3 })
}

@Test
fun `findLast returns null when no match`() {
    val numbers = listOf(1, 2, 3)
    assertNull(numbers.myFindLast { it > 10 })
}

@Test
fun `findLast last Engineering employee`() {
    val result = employees.myFindLast { it.department == "Engineering" }
    assertEquals("Grace", result?.name)
}

Green:最小實作

inline fun <T> Iterable<T>.myFind(predicate: (T) -> Boolean): T? {
    return myFirstOrNull(predicate)
}

inline fun <T> Iterable<T>.myFindLast(predicate: (T) -> Boolean): T? {
    return myLastOrNull(predicate)
}

沒錯,兩個都是一行搞定,直接委派給 day 06 手刻過的 myFirstOrNullmyLastOrNull

Refactor:往 stdlib 的寫法靠近

myFind 沒東西好重構,委派給 myFirstOrNull 本來就是 stdlib 的寫法。stdlib 的 find 長這樣

@kotlin.internal.InlineOnly
public inline fun <T> Iterable<T>.find(predicate: (T) -> Boolean): T? {
    return firstOrNull(predicate)
}

findLast 則是這樣

@kotlin.internal.InlineOnly
public inline fun <T> Iterable<T>.findLast(predicate: (T) -> Boolean): T? {
    return lastOrNull(predicate)
}

@kotlin.internal.InlineOnly
public inline fun <T> List<T>.findLast(predicate: (T) -> Boolean): T? {
    return lastOrNull(predicate)
}

findfindLast 都是別名,一個對應 firstOrNull,一個對應 lastOrNull,都加了 @InlineOnly 註解。@InlineOnly 表示這個函式只在 inline 展開後的 bytecode 裡存在,不會產生實際的方法。list.find { it > 3 } 編譯後跟 list.firstOrNull { it > 3 } 完全一樣,連中間的函式呼叫都沒有

比較有意思的是 findLastfind 多了一個 List 的多載。find 不需要,firstOrNull 找到就停,正向走訪已經是最好的走法;lastOrNull 不一樣,day 06 提過它對 List 有從尾端往回走的快版本

問題是多載解析是靜態的。如果只宣告 Iterable<T>.findLast,就算呼叫端的接收者是 List,函式裡那句 lastOrNull(predicate) 看到的接收者型別還是 Iterable<T>,只會解析到全掃版。多宣告一個 List<T>.findLastList 接收者才會優先選到它,反向走訪那個最佳化也才跟著吃得到

所以我們的 myFindLast 也要補上這個多載

inline fun <T> List<T>.myFindLast(predicate: (T) -> Boolean): T? {
    return myLastOrNull(predicate)
}

day 06 已經幫 myLastOrNull 寫過 List 的反向版本,這裡照樣委派就好。後面 indexOfLastList 多載是同一個脈絡

為什麼要有這兩個別名?語意。有些場景用 find 讀起來更自然:「在員工裡找出第一個工程師」比「取出第一個工程師或 null」口語化一些

TDD 實作 myIndexOfFirst 與 myIndexOfLast

Red:先寫測試

@Test
fun `indexOfFirst returns index of first match`() {
    val numbers = listOf(10, 20, 30, 40, 50)
    assertEquals(3, numbers.myIndexOfFirst { it > 30 })
}

@Test
fun `indexOfFirst returns -1 when no match`() {
    val numbers = listOf(1, 2, 3)
    assertEquals(-1, numbers.myIndexOfFirst { it > 10 })
}

@Test
fun `indexOfFirst on empty list returns -1`() {
    val empty = emptyList<Int>()
    assertEquals(-1, empty.myIndexOfFirst { it > 0 })
}

@Test
fun `indexOfFirst Engineering employee`() {
    assertEquals(0, employees.myIndexOfFirst { it.department == "Engineering" })
}

indexOfLast 一樣照著來,資料照用,有命中的那兩個改成期望最後一個符合的位置

@Test
fun `indexOfLast returns index of last match`() {
    val numbers = listOf(10, 20, 30, 40, 50)
    assertEquals(4, numbers.myIndexOfLast { it > 30 })
}

@Test
fun `indexOfLast returns -1 when no match`() {
    val numbers = listOf(1, 2, 3)
    assertEquals(-1, numbers.myIndexOfLast { it > 10 })
}

@Test
fun `indexOfLast on empty list returns -1`() {
    val empty = emptyList<Int>()
    assertEquals(-1, empty.myIndexOfLast { it > 0 })
}

@Test
fun `indexOfLast Engineering employee`() {
    assertEquals(6, employees.myIndexOfLast { it.department == "Engineering" })
}

找不到回傳 -1 而不是 null。為什麼?跟 Java 的 List.indexOf() 慣例一致。Java 開發者已經習慣 -1 代表「沒找到」,Kotlin 選擇保持這個慣例來降低轉換成本

這個選擇看起來跟 day 06 講的 null safety 哲學有點矛盾,那邊講 Kotlin 寧可用 OrNull 也不靠 magic value。差別在於 Java 互通的代價,indexOfkotlin.collections.List 的成員方法,在 JVM 上會對應到 java.util.List 的同名方法,如果改回傳 Int? 就會破壞跟 Java 程式碼的互通。這種地方 Kotlin 寧可保持一致也不追求理想,實用優先

Green:最小實作

inline fun <T> Iterable<T>.myIndexOfFirst(predicate: (T) -> Boolean): Int {
    var index = 0
    for (element in this) {
        if (predicate(element)) {
            return index
        }
        index++
    }
    return -1
}

跟 day 05 myFilterIndexed 一樣,手動維護 index 計數器。找到第一個符合的就馬上帶著 index 回去

myIndexOfLast 不能提前 return,得掃完

inline fun <T> Iterable<T>.myIndexOfLast(predicate: (T) -> Boolean): Int {
    var lastIndex = -1
    var index = 0
    for (element in this) {
        if (predicate(element)) {
            lastIndex = index
        }
        index++
    }
    return lastIndex
}

lastIndex 初始化為 -1。每次遇到符合條件的元素就更新 lastIndex,走訪完再回傳。如果一個都沒找到,lastIndex 還是 -1

跟 day 06 myLast(predicate) 的模式一樣:myLast 記住最後一個符合的元素,myIndexOfLast 記住最後一個符合的位置

Refactor:往 stdlib 的寫法靠近

正向版本沒什麼好改的。不過 indexOfLast 其實有兩種走法:正向掃完、記住最後一個符合的位置(我們的寫法),或者從尾端反向走訪,碰到第一個符合的就提前回傳。Iterable 只能用前者,iterator 只會往前走;List 才能反向,所以跟 myFindLast 一樣補一個 List 的多載

inline fun <T> List<T>.myIndexOfLast(predicate: (T) -> Boolean): Int {
    val iterator = listIterator(size)
    while (iterator.hasPrevious()) {
        if (predicate(iterator.previous())) {
            return iterator.nextIndex()
        }
    }
    return -1
}

listIterator(size) 拿到一個停在尾端的 iteratorhasPrevious() / previous() 一路往回走。previous() 之後游標會退一格,nextIndex() 剛好就是那個元素的位置。從後面找「最後一個」,碰到就直接回傳,不用掃完整個集合

TDD 實作 myIndexOf 與 myLastIndexOf

Red:先寫測試

@Test
fun `indexOf returns first occurrence`() {
    val numbers = listOf(1, 2, 3, 2, 1)
    assertEquals(1, numbers.myIndexOf(2))
}

@Test
fun `indexOf returns -1 when not found`() {
    val numbers = listOf(1, 2, 3)
    assertEquals(-1, numbers.myIndexOf(10))
}

@Test
fun `lastIndexOf returns last occurrence`() {
    val numbers = listOf(1, 2, 3, 2, 1)
    assertEquals(3, numbers.myLastIndexOf(2))
}

@Test
fun `lastIndexOf returns -1 when not found`() {
    val numbers = listOf(1, 2, 3)
    assertEquals(-1, numbers.myLastIndexOf(10))
}

重複元素是這組的重點。2 在 index 1 和 3 各出現一次,myIndexOf 要拿前面那個,myLastIndexOf 要拿後面那個

Green:最小實作

fun <T> Iterable<T>.myIndexOf(element: T): Int {
    var index = 0
    for (item in this) {
        if (item == element) {
            return index
        }
        index++
    }
    return -1
}

fun <T> Iterable<T>.myLastIndexOf(element: T): Int {
    var lastIndex = -1
    var index = 0
    for (item in this) {
        if (item == element) {
            lastIndex = index
        }
        index++
    }
    return lastIndex
}

骨架跟 myIndexOfFirst / myIndexOfLast 完全一樣,只是把 predicate(item) 換成 item == elementmyIndexOf(element)myIndexOfFirst { it == element } 是等價的,stdlib 兩個都留著,前者不需要 Lambda,用起來更簡潔

注意這兩個函式不需要 inline。沒接收 Lambda,就不會有 FunctionN 物件的問題

Refactor:往 stdlib 的寫法靠近

List 本身就有 indexOf 成員方法,接收者是 List 的時候,我們手動數的這一圈根本不用跑。stdlib 的做法是開頭先擋一下

fun <T> Iterable<T>.myIndexOf(element: T): Int {
    if (this is List) {
        return this.indexOf(element)
    }
    var index = 0
    for (item in this) {
        if (item == element) {
            return index
        }
        index++
    }
    return -1
}

this is List 之後 this 會 smart cast 成 List<T>this.indexOf(element) 呼叫到的就是成員方法。真正會走到下面手動計數的,只剩 Set 這種不是 ListIterable

myLastIndexOf 同一招,開頭換成 if (this is List) return this.lastIndexOf(element)

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.kt

stdlib 的 Iterable<T>.indexOfFirst 跟我們幾乎一樣,只多一行

public inline fun <T> Iterable<T>.indexOfFirst(predicate: (T) -> Boolean): Int {
    var index = 0
    for (item in this) {
        checkIndexOverflow(index)
        if (predicate(item))
            return index
        index++
    }
    return -1
}

多出來的 checkIndexOverflow(index) 是在防溢位:Iterable 沒有大小上限,元素數量一旦超過 Int.MAX_VALUEindex++ 會繞回負數,stdlib 選擇在這時直接丟例外,而不是默默回傳一個錯的位置。另外還有一個定義在 List<T> 上的多載,Listsize 本身就是 Int,不可能溢位,所以沒有這個檢查,那個版本才是跟我們寫的一模一樣

List<T>.indexOfLast 就是剛剛 Refactor 那個反向走訪,stdlib 也是這樣寫

public inline fun <T> List<T>.indexOfLast(predicate: (T) -> Boolean): Int {
    val iterator = this.listIterator(size)
    while (iterator.hasPrevious()) {
        if (predicate(iterator.previous())) {
            return iterator.nextIndex()
        }
    }
    return -1
}

indexOf 在 stdlib 裡有兩層。kotlin.collections.List 介面本身就宣告了 indexOf 成員方法(JVM 上對應到 java.util.List,但這是 common 宣告,Native 和 JS 上同樣有);另一層是 Iterable<T>.indexOf(element) 擴充函式,開頭那個 if (this is List) 短路就是我們剛剛 Refactor 抄過來的,不是 List 才手動計數

小結

find 是 firstOrNull 的語意包裝,indexOfFirst / indexOfLast 是帶 predicate 的位置尋找,indexOf / lastIndexOf 是用 == 的位置尋找。模式都在前幾篇看過了,這篇算是尋找系列的最後幾塊拼圖

下一篇是篩選與尋找篇的收尾:containselementAt

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 07:any / all / none / count — 集合的布林判斷與計數
下一篇
Kotlin Lambda 從零開始 Day 09:contains / elementAt — 存在性與直接存取
系列文
Kotlin Lambda 從零開始15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言